AWS Database Blog
Migrate CLR code to AWS Lambda when upgrading Amazon RDS for SQL Server to 2025
In Addressing CLR assembly deprecation in Amazon RDS for SQL Server, we explained that Microsoft SQL Server 2016 reached end of extended support on July 14, 2026, and that Amazon Relational Database Service (Amazon RDS) for SQL Server 2017 and later doesn’t support user-defined SQL Common Language Runtime (CLR) assemblies. This is because CLR strict security treats every assembly as UNSAFE, and AWS doesn’t provide the sysadmin permissions needed to work around it on RDS for SQL Server.
That post presents four replacement strategies for your CLR code:
- Option A – Upgrade to RDS for SQL Server 2025 and invoke AWS services (such as AWS Lambda) through the native sp_invoke_external_rest_endpoint stored procedure.
- Option B – Convert to native Transact-SQL (T-SQL).
- Option C – Move the logic to the application layer.
- Option D – Migrate to SQL Server on Amazon Elastic Compute Cloud (Amazon EC2) (retains CLR).
This post is a hands-on walkthrough of Option A, for the cases where the CLR logic has no native T-SQL equivalent and you want to stay on fully managed Amazon RDS.
When Option A is the right choice
Option A is for the cases that have no native equivalent, where the CLR assembly does procedural work that T-SQL cannot express.
Problem
Consider a task where you need to load a delimited file where a single logical record can span multiple physical lines, because a quoted field contains an embedded line break, such as a multi-line address or a notes field. The file is RFC 4180 compliant, meaning a field wrapped in quotes is allowed to contain the row terminator. Native T-SQL ingestion tooling cannot parse this correctly, and there’s no built-in function that handles quote-aware, multi-line record boundaries.
Consider this record:
“AnyCompany, Inc.”,“123 Any St, Suite 1
Building A”,“Anytown”
That’s one logical row with three fields, but it spans two physical lines and the first field contains a comma. Native tooling gets this wrong:
- STRING_SPLIT splits on every comma, so “AnyCompany, Inc.” becomes two columns.
- BULK INSERT … FORMAT=‘CSV’ treats the embedded newline as a row terminator, so the single record is torn into two malformed rows. This is a documented limitation. The CSV format doesn’t support fields that contain the row terminator inside quotes.
That stateful parsing is exactly the kind of logic with no T-SQL equivalent, so it’s a legitimate candidate for Option A rather than an anti-pattern.
Solution
Because the parsing logic can’t be expressed in T-SQL but works fine as ordinary procedural code, the approach is to keep that logic intact and move it out of the database engine into a managed compute service that SQL Server can call. This preserves the exact quote-aware, multi-line behavior you already have while removing the CLR assembly and the sysadmin/UNSAFE privileges it required.
We use that CSV parser as the running example. Concretely, we:
- Move the parser’s core logic (unchanged) into an AWS Lambda function that accepts CSV text as JSON and returns the parsed rows as a JSON array.
- Expose the function through Amazon API Gateway with an API key and usage plan.
- Call it from SQL Server 2025 with sp_invoke_external_rest_endpoint, wrapped in a stored procedure that has the same signature as the original CLR function, so existing callers do not change.
The result is a fully managed workaround that parses the multi-line file correctly, with no CLR and no sysadmin access. All the code, including the Lambda project, the reference CLR assembly, and the SQL scripts, is in the accompanying repository on GitHub. You can take reference from the repository to modify your own CLR code and deploy as the solution described in this post.
Prerequisites
- An Amazon RDS for SQL Server 2025 instance, with the external REST endpoint enabled parameter set to 1 in a custom parameter group. To modify a parameter in a parameter group, see Modifying parameters in a DB parameter group in Amazon RDS.If the RDS instance is in a private subnet, outbound internet access is required for sp_invoke_external_rest_endpoint. Provision a NAT gateway (in a public subnet with an internet gateway) and add a 0.0.0.0/0 route to the NAT in the RDS subnets’ route table. This keeps RDS private (egress-only) while allowing the outbound HTTPS call.
- SQL Server Management Studio (SSMS) connected with the master user of RDS.
- Familiarity with Git commands, .NET SDK and Amazon.Lambda.Tools, if you build the package yourself (the repository includes a prebuilt .zip).
Solution overview
The following flowchart shows the end-to-end path from the SQL Server stored procedure call through API Gateway and Lambda, back to T-SQL result rows by using OPENJSON(). The two highlighted nodes show that everything starts and ends in T-SQL. You keep the proven business logic from your CLR assembly and only rewrite the thin wrapper that connects it to the host. The SQL Server entry point becomes an AWS Lambda handler.
Figure 1: End-to-end path from a SQL Server stored procedure call through Amazon API Gateway and AWS Lambda back to T-SQL result rows
The preceding diagram shows how to replace a legacy SQL CLR assembly with serverless architecture, because Amazon RDS for SQL Server doesn’t allow the custom CLR assemblies you’d run on premises. The flow:
1. Amazon RDS for SQL Server 2025 – Instead of calling into a CLR assembly inside the database, a stored procedure uses the native sp_invoke_external_rest_endpoint to make an outbound HTTPS call. The outbound HTTPS call originates from the RDS instance and, for a private-subnet RDS, egresses through a NAT gateway to the internet. The API Gateway sees the request arriving from the NAT gateway’s Elastic IP address (a stable public IP), which is what you allowlist in the API resource policy. Credentials and the API key are stored securely through a DATABASE SCOPED CREDENTIAL.
2. Amazon API Gateway – Receives that HTTPS POST, validates the API key against a usage plan, and forwards the request to Lambda using proxy integration.
3. AWS Lambda (.NET) – Runs the same parsing/business logic that used to live in the CLR assembly, now as managed .NET code outside the database. It returns its result as JSON.
4. T-SQL Result Set – SQL Server takes the JSON response back and parses it into rows and columns, so the caller gets a normal result set. The result looks just as if the old in-database CLR function had returned it.
In short: we’re moving custom .NET logic out of the database (where CLR isn’t supported on RDS) into a Lambda function reached through API Gateway, while keeping the SQL-side experience the same. You call a proc and get a result set back.
Note: The API key here is a long-lived static secret (stored encrypted in a DATABASE SCOPED CREDENTIAL, so it isn’t visible in the SQL). For production, consider an authentication method aligned to your security needs, such as API Gateway authorization that uses AWS Identity and Access Management (IAM) and short-lived SigV4 credentials rather than a static key.
Step 1: Build the Lambda deployment package
The accompanying repository contains the CsvParserLambda .NET project, a handler that reuses your CLR parsing logic and returns the parsed rows as JSON. Clone it and build the deployment package according to the steps in the repository README.md.
This step creates CsvParserLambda.zip, to be used in Step 2. For how to convert your own CLR .cs into a Lambda handler (what to keep and what to replace) and the full build/packaging details, see Migrating your own CLR code to AWS Lambda in the repository.
Step 2: Create the Lambda function
In this step, you create an execution role, attach the basic execution policy, and deploy the function from the ZIP package you built in Step 1.
2.1 Create the Lambda execution role
2.2 Attach the basic execution policy (CloudWatch Logs)
2.3 Create the function from the ZIP built in Step 1
Replace ACCOUNT_ID in the preceding command with the AWS account ID of your environment.
To verify the function in isolation, open the Test tab in the AWS Lambda console for the function clr-csv-parser created earlier and create an event that mimics an API Gateway proxy request:
A successful response has “statusCode”: 200 and a body containing “success”:true with the parsed rows.
Everything the parser is meant to prove is working:
- success: true, rowCount: 1 (header correctly skipped).
- Field 1: AnyCompany, Inc. (comma inside quotes preserved as one field).
- Field 2:
123 Any St, Suite 1\nBuilding A(the multi-line quoted field kept its internal newline, which is the whole reason this Lambda exists, because BULK INSERT can’t do this). - Field 3: Anytown.
Step 3: Create the REST API in API Gateway
Amazon API Gateway gives the Lambda function a secure, public HTTPS endpoint that SQL Server can call. This is necessary because sp_invoke_external_rest_endpoint requires an HTTPS URL with a publicly trusted TLS certificate. It cannot invoke a Lambda function directly.
We create a REST API with a single POST /parse method integrated with the parser function. It uses Lambda proxy integration, so the raw request body is passed straight through to the handler. This walkthrough uses a REGIONAL (public) API and we implement secure access to this regional API with a resource policy allowing only the NAT Elastic IP address.
3.1 Create the API
3.2 Get the root resource id (“/”)
3.3 Create the /parse resource
3.4 Create the POST method with API key required
3.5 Wire POST to Lambda using proxy integration (AWS_PROXY)
3.6 Grant API Gateway permission to invoke the Lambda
3.7 Deploy to the “prod” stage
3.8 Your endpoint is:
You will need the preceding output in Step 5.2, when we create the procedure in Amazon RDS for SQL Server.
3.9 Securing access to the API
The resource policy on the API can restrict by aws:SourceIp, the public IP that API Gateway sees the request arriving from. For sp_invoke_external_rest_endpoint, the outbound path depends on the subnet’s routing:
- If RDS is in a public subnet (route table has a 0.0.0.0/0 route to an internet gateway), outbound traffic egresses using the instance’s own public IP (through the internet gateway).To get the RDS public IP address, use the nslookup command:
- If RDS is in a private subnet and uses a NAT gateway, then outbound traffic to the internet goes through the NAT, so you allowlist the NAT Elastic IP address.To get the public Elastic IP address of the NAT gateway, check the Amazon Virtual Private Cloud (Amazon VPC) console or run the following command:
Build the resource policy and attach it to the API through the console or update-rest-api, as described in Create and attach an API Gateway resource policy to an API.
After adding the resource policy, redeploy the API so the policy takes effect by running the deployment command from Step 3.7 again.
Step 4: Create an API key and usage plan
The API key authenticates calls from SQL Server, so the endpoint isn’t open to anonymous callers. The usage plan enforces throttling and quota limits to protect the Lambda function from runaway or excessive requests. When burst or rate limits are exceeded, API Gateway returns HTTP 429 “Too Many Requests” (or “Limit Exceeded” for quota) before the request reaches Lambda. T-SQL surfaces the same 429 error code. Together, they give you a managed access-control and rate-limiting layer in front of the parser. For more information, see Usage plans and API keys for REST APIs in API Gateway.
4.1 Create the API key
4.2 Retrieve the API key value
4.3 Create a usage plan with throttling and quota, associated to the prod stage
4.4 Associate the API key with the usage plan
Step 5: Configure and invoke the endpoint from SQL Server 2025
With the Lambda function and API Gateway endpoint in place, the final step is to call them from SQL Server 2025. This involves storing the API key securely, creating a stored procedure that invokes the endpoint, and running tests to confirm the end-to-end flow. The following sections walk through each part, starting with credential setup.
5.1 Store the API key in a database scoped credential
We store the API key inside SQL Server rather than embedding it in the query text. A database master key encrypts the credential at rest, and the database scoped credential holds the API key as an HTTP header (x-api-key) that the sp_invoke_external_rest_endpoint procedure automatically attaches to each call. This keeps the key out of stored procedure definitions, query plans, and logs. The procedure references the credential by name and never sees the raw key.
Create the master key once per database. The IF NOT EXISTS check makes the script safe to re-run. Also, create a sample database named DemoDB using the script in the repository for this testing.
Create the database scoped credential using the following command in SQL Server:
Replace placeholders with your actual values:
Two details matter for it to work:
Credential name = the endpoint’s base URL, with a trailing slash. SQL Server matches the credential to the call by treating the name as a path-boundary prefix of the request URL. Without the trailing slash, the match fails and you get a “cannot find the credential” error.
Identity = ‘HTTPEndpointHeaders’ tells SQL Server to send the credential’s secret as request headers (as opposed to a query string), which is how API Gateway expects the API key.
5.2 Create a procedure that mirrors the CLR function
This procedure is the drop-in replacement for the original CLR function we used in SQL Server 2016 to replicate the test. It has the same parameters (@csvData, @delimiter, @skipHeader), so existing callers don’t change. It builds a JSON request with FOR JSON, invokes the Lambda function through API Gateway with sp_invoke_external_rest_endpoint, and returns the parsed rows by using OPENJSON. For a line-by-line explanation, see How the procedure works in the repository.
Consider using the ENCRYPTION option to create the stored procedure for enhanced security if required. Also, to mitigate the risk of unauthorized access or data transfer, follow the security best practices described in Ways to mitigate risk of unauthorized access or transfer of data.
5.3 Test with data the native parser can’t handle
Returns one row. “AnyCompany, Inc.” stays a single field and the multi-line address is preserved, as shown in the following screenshot.
Performance and cost
Unlike a CLR assembly, which runs in-process in microseconds, this pattern makes a network round-trip on every call. As a result, it suits batch or file-level operations, such as one call per file in an extract, transform, and load (ETL) run, rather than per-row calls inside a hot query. In our testing, parsing a 1,000-row multi-line file took about 235 ms end-to-end on a warm function. The SQL-side work is negligible (approximately 10 ms), so the round-trip dominates. The first invocation after an idle period may add roughly 1–3 seconds of Lambda cold-start latency. Subsequent calls in the same burst run warm. If you need predictable latency for time-sensitive jobs, enable Provisioned Concurrency on the function to keep it initialized. Round-trip latency depends on the network path between RDS and API Gateway. Keeping the Lambda and API Gateway in the same AWS Region as RDS minimizes it. You can estimate the execution time of Lambda separately from the network by looking at the function’s Amazon CloudWatch logs.
Tip: INSERT … EXEC dbo.ParseCSV_Lambda holds locks on the target table for the entire network round-trip (including any Lambda cold start). For loads into a permanent table, stage the parsed rows into a temp table first (INSERT INTO #Staging EXEC dbo.ParseCSV_Lambda …), then do a fast local INSERT from the temp table into the target. This keeps the round-trip latency out of the real table’s lock window. (Section C of script 05-tests.sql shows the temp-table staging pattern.)
For very large CSV files, split the payload into chunks before calling the procedure, such as one chunk per 1,000–5,000 rows. Both sp_invoke_external_rest_endpoint and Lambda’s synchronous invocation have a payload size limit (6 MB), so chunking avoids hitting that ceiling and keeps individual round-trip times predictable. As a rule of thumb, if a file is larger than 5–6 MB size, chunk it into smaller parts.
For batch workloads the cost is minimal. A typical nightly job of 1,000 file invocations per month costs well under $1/month across AWS Lambda and Amazon API Gateway combined (assuming approximately 128–512 MB memory, sub-second durations, and AWS Free Tier eligible request volumes). The dominant cost remains the Amazon RDS instance itself. Use the AWS Pricing Calculator to model your own request volume.
Clean up
To avoid ongoing charges, remove the resources you created. First, drop the objects in SQL Server by running the script 06-cleanup.sql in the repository. Delete the usage plan and API key. Then delete the clr-csv-parser Lambda function. Finally, delete any RDS instances, custom parameter groups, and Amazon Simple Storage Service (Amazon S3) test files that you created solely for this walkthrough.
Conclusion
In this post, we showed you how to replace an unsupported CLR assembly on Amazon RDS for SQL Server with an AWS Lambda function called natively from T-SQL through sp_invoke_external_rest_endpoint. With this pattern, you can retire CLR code while staying on fully managed Amazon RDS and reusing your existing .NET logic. Use this pattern for logic with no native equivalent in SQL Server. To get started, clone the sample code repository and run the walkthrough. For more detail, see Amazon RDS for SQL Server, AWS Lambda, and the parent post, Addressing CLR assembly deprecation in Amazon RDS for SQL Server.
